[Previous] [Next]

Object Hierarchies

So far, I have shown you how you can store complex pieces of logic in a class and reuse these pieces elsewhere in your application and in your future projects with very little effort. But what you have seen so far are individual classes that solve particular programming problems. The real power of objects is striking when you use them to create larger cooperative structures, also known as object hierarchies.

Relationships Among Objects

If you want to aggregate multiple objects in larger structures, you need a way to establish relationships among them.

One-to-one relationships

In the world of OOP, establishing a relationship between two objects is as simple as providing the former object with an object property that points to the latter. For example, a typical CInvoice object might expose a Customer property (which points to a Customer object) and two properties, SendFrom and ShipTo, that can contain references to a CAddress object:

' In the CInvoice class module
Public Customer As CCustomer           ' In a real app, these would
Public SendFrom As CAddress            ' be implemented as pairs
Public ShipTo As CAddress              ' of property procedures.

This code declares that the class is able to support these relationships. You actually create the relationships at run time when you assign a non-Nothing reference to the properties:

Dim inv As New CInvoice, cust As CCustomer
inv.Number = GetNextInvoiceNumber()    ' A routine defined somewhere else
' For simplicity, let's not worry about how the CUST object is created.
Set cust = GetThisCustomer()           ' This returns a CCustomer object.
Set inv.Customer = cust                ' This creates the relationship.
' You don't always need an explicit variable.
Set inv.SendFrom = GetFromAddress()    ' This returns a CAddress object,
Set inv.ShipTo = GetToAddress()        ' as does this one.

Once the relationship has been established, you can start playing with the infinite possibilities offered by VBA and write code that is extremely concise and elegant:

' In the CInvoice class module
Sub PrintHeader(obj As Object)
    ' Print the invoice on a form, PictureBox, or the Printer.
    obj.Print "Number " & Number
    obj.Print "Customer: " & Customer.Name
    obj.Print "Send From: " & SendFrom.CompleteAddress
    obj.Print "Ship To: " & ShipTo.CompleteAddress
End Sub

Being able to deal with data already logically grouped in subproperties noticeably improves the quality and style of your code. Because, in most cases, the ShipTo address coincides with the address of the customer, you can offer a reasonable default for that property. You only have to delete the Public ShipTo member in the declaration section and add the following code:

Private m_ShipTo As CAddress

Property Get ShipTo() As CAddress
    If m_ShipTo Is Nothing Then
        Set ShipTo = Customer.Address
    Else
        Set ShipTo = m_ShipTo
    End If
End Property
Property Let ShipTo(newValue As CAddress)
    Set m_ShipTo = newValue
End Property

Because you aren't touching the class's interface, the rest of the code—both inside and outside the class itself—continues to work without a glitch.

Once the relation is set, there's no way to accidentally invalidate it by tampering with the involved objects. In the CInvoice example, even if you explicitly set the cust variable to Nothing—or let it go out of scope, which has the same effect—Visual Basic won't destroy the CCustomer instance, and therefore the relationship between Invoice and Customer will continue to work as before. This isn't magic; it's simply a consequence of the rule that states that an object instance is released only when all the object variables that reference it are set to Nothing. In this case, the Customer property in the CInvoice class keeps that particular CCustomer instance alive until you set the Customer property to Nothing or the CInvoice object itself is destroyed. You don't need to explicitly set the Customer property to Nothing in the Class_Terminate event of the CInvoice class: When an object is released, Visual Basic neatly sets all its object properties to Nothing before proceeding with the actual deallocation. This operation decreases the reference counter of all the referenced objects, which in turn are destroyed if their reference counter goes to 0. In larger object hierarchies, it often happens that destroying an object causes a complex chain of cascading deallocation operations. Fortunately, you don't have to worry about it because it's Visual Basic's business, not yours.

One-to-many relationships

Things are a bit more complex when you create one-to-many relationships among objects. There are countless occasions when one-to-many relationships are necessary. For example, your CInvoice class might have to point to multiple product descriptions. Let's see how this problem can be solved efficiently.

For your object-oriented experiment, you need an auxiliary class, CInvoiceLine, which holds information about a product, ordered quantity, and unit price. What follows is a very simple implementation of it, with no validation at all. (Don't use this implementation in your real invoicing software, please!) The version on the companion CD also has a constructor, a Description property, and other features, but you need just three variables and a property procedure to get started:

' A workable CInvoiceLine class module
Public Qty As Long
Public Product As String
Public UnitPrice As Currency

Property Get Total() As Currency
    Total = Qty * UnitPrice
End Property

You can choose, basically, from two ways to implement such one-to-many relations: You can use an array of object references, or you can use a collection. The array solution is trivial:

' We can't expose arrays as Public members.
Private m_InvoiceLines(1 To 10) As CInvoiceLine

Property Get InvoiceLines(Index As Integer) As CInvoiceLine
    If Index < 1 Or Index > 10 Then Err.Raise 9   ' Subscript out of range
    Set InvoiceLines(Index) = m_InvoiceLines(Index)
End Property
Property Set InvoiceLines(Index As Integer, newValue As CInvoiceLine)
    If Index < 1 Or Index > 10 Then Err.Raise 9   ' Subscript out of range
    Set m_InvoiceLines(Index) = newValue
End Property

' In the client code
' (Assumes that we defined a constructor for the CInvoiceLine class)
Set inv.InvoiceLine(1) = New_CInvoiceLine(10, "Monitor ZX100", 225.25)
Set inv.InvoiceLine(2) = New_CInvoiceLine(14, "101-key Keyboard", 19.99)
' etc.

As easy as they are to implement, arrays of object references have a lot of problems, especially because it isn't clear how you can use them effectively when you don't know in advance how many child CInvoiceLine items you need. In fact, I suggest that you use them only if you're absolutely sure that the number of possible related objects is well defined in advance.

The collection solution is more promising because it doesn't pose any limit to the number of related objects, and also because it permits a more natural, OO-like syntax in the client code. Besides, you can declare a collection (unlike an array) as a Public member, so the code in the class module is even simpler:

' In the CInvoice class
Public InvoiceLines As New Collection

' In the client code (no need to keep track of line index)
inv.InvoiceLines.Add New_CInvoiceLine(10, "Monitors ZX100", 225.25)
inv.InvoiceLines.Add New_CInvoiceLine(14, "101-key Keyboards", 19.99)

Using a collection improves the code inside the CInvoice class in other ways as well. See how easily you can enumerate all the lines in an invoice:

Sub PrintBody(obj As Object)
    ' Print the invoice body on a form, PictureBox, or the Printer.
    Dim invline As CInvoiceLine, Total As Currency
    For Each invline In InvoiceLines
        obj.Print invline.Description
        Total = Total + invline.Total
    Next
    obj.Print "Grand Total = " & Total
End Sub

This solution has one major drawback, though. It leaves the CInvoice class completely at the mercy of the programmer who uses it. To see what I mean, just try out this bogus code:

inv.InvoiceLines.Add New CCustomer          ' No error!

This isn't surprising, of course: Collection objects store their values in Variants, so they accept anything you throw at them. This seemingly innocent detail undermines the robustness of the CInvoice class and completely undoes all our efforts. Must we tolerate it?

Collection Classes

The solution to the robustness problem comes in the form of collection classes. These are special classes that you write in plain Visual Basic code and that closely resemble native Collection objects. Since you are in control of their implementation, you can establish a particular syntax for their methods and check what's being added to the collection. As you'll see, they're so alike that you won't even need to retouch the client code.

Collection classes are an application of the concept of inheritance that I described earlier in this chapter. A collection class keeps a reference to a private collection variable and exposes to the outside a similar interface so that the client code believes it's interacting with a real Collection. To enhance the CInvoice example, you therefore need a special CInvoiceLines collection class. (It's customary for the name of a collection class to be the plural form of the name of the base class.) Now that you have mastered the secrets of inheritance, you should have no problem understanding how the code below works.

' The private collection that holds the real data
Private m_InvoiceLines As New Collection

Sub Add(newItem As CInvoiceLine, Optional Key As Variant, _
    Optional Before As Variant, Optional After As Variant)
    m_InvoiceLines.Add newItem, Key
End Sub
Sub Remove(index As Variant)
    m_InvoiceLines.Remove index
End Sub
Function Item(index As Variant) As CInvoiceLine
    Set Item = m_InvoiceLines.Item(index)
End Function
Property Get Count() As Long
    Count = m_InvoiceLines.Count
End Property

You need to do two more things to make your CInvoiceLines collection class perfectly mimic a standard Collection: You must provide support for the default item and for enumeration.

Make Item the default member

Programmers are used to omitting the Item member's name in code when working with Collection objects. To support this feature in your collection class, you just have to make Item the default member of the class, which you do by issuing the Procedure Attributes command from the Tools menu, selecting Item in the uppermost combo box, expanding the dialog box, and typing 0 (zero) in the ProcID field. Or you can select (default) in the drop-down list. This procedure was explained in more detail in Chapter 6.

Add support for enumeration

No collection class could hope to win the hearts of hardcore Visual Basic developers if it didn't support the For Each statement. Visual Basic lets you add such support, though in a rather cryptic way. First add the following procedure to your class module:

Function NewEnum() As IUnknown
    Set NewEnum = m_InvoiceLines.[_NewEnum]
End Function

and then invoke the Procedure Attributes dialog box. Then select the NewEnum member, assign it a ProcID equal to -4, tick the Hide This Member check box, and close the dialog box.

NOTE
Understanding how this weird technique works requires some intimate knowledge of OLE mechanisms, in particular the IEnumVariant interface. Without going into too many details, suffice it to say that when an object appears in a For Each statement, it has to expose an auxiliary enumerator object. OLE conventions dictate that the class must provide this enumerator object through a function whose ProcID is equal to -4. At run time, Visual Basic calls the corresponding procedure and uses the returned enumerator object to progress through the loop iteration.

Unfortunately, you can't manufacture an enumerator object using plain Visual Basic code, but you can borrow the enumerator object exposed by the private Collection object, which is exactly what the NewEnum function shown previously does. Collection objects expose their enumerators using a hidden method named _NewEnum (search for it in the Object Browser with the Show Hidden Members option enabled), which is an invalid name in VBA and must therefore be enclosed in a pair of square brackets. By the way, Dictionary objects don't expose any Public enumerator objects, and for this reason you can't use them as the basis of your collection classes.

Testing the collection class

You can now improve the CInvoice class by making it use your new CInvoiceLines class instead of the standard Collection object:

' In the declaration section of CInvoice
Public InvoiceLines As New CInvoiceLines

The mere fact that the CInvoiceLines class checks the type of object passed to its Add method is enough to morph the CInvoice class into a secure object. Interestingly, you don't strictly need any other changes in code, either inside or outside the class. Just press F5 to see it for yourself.

Improving the collection class

If collection classes were useful only to improve the robustness of your code, they would be worth the effort. But the real fun only begins here. Since you have complete control over what happens inside your class, you can decide to improve it with new methods or modify how existing ones react to their arguments. For example, you can have the Item method return Nothing if the element doesn't exist, instead of obnoxiously raising an error as regular collections do:

Function Item(index As Variant) As CInvoiceLine
    On Error Resume Next
    Set Item = m_InvoiceLines.Item(index)
End Function

Or you can add an explicit Exists function as shown below.

Function Exists(index As Variant) As Boolean
    Dim dummy As CInvoiceLine
    On Error Resume Next
    Set dummy = m_InvoiceLines.Item(index)
    Exists = (Err = 0)
End Function

You can also supply a handy Clear method:

Sub Clear()
    Set m_InvoiceLines = New Collection
End Sub

All these custom members are completely generic, and you can often implement them in most of the collection classes you write. Methods and properties that are specific to the particular collection class are undoubtedly more interesting:

' Evaluate the total of all invoice lines.
Property Get Total() As Currency
    Dim result As Currency, invline As CInvoiceLine
    For Each invline In m_InvoiceLines
        result = result + invline.Total
    Next
    Total = result
End Property

' Print all invoice lines.
Sub PrintLines(obj As Object)
    Dim invline As CInvoiceLine
    For Each invline In m_InvoiceLines
        obj.Print invline.Description
    Next
End Sub

These new members simplify the structure of the code in the main class:

' In the CInvoice class
Sub PrintBody(obj As Object)
    InvoiceLines.PrintLines obj
    obj.Print "Grand Total = " & InvoiceLines.Total
End Sub

Of course, the total amount of code doesn't vary, but you have distributed it in a more logical way. Each object is responsible for what happens inside it. In real projects, this approach has many beneficial consequences in code testing, reuse, and maintenance.

Add real constructors to the game

Collection classes offer one additional benefit that object-oriented programmers can't live without: real constructors. I have already explained that the lack of constructor methods is a major defect in the otherwise decent support for encapsulation supplied by Visual Basic.

If you wrap a collection class around a base class—as CInvoiceLines and CInvoiceLine do, respectively—you can create a constructor by adding a method to the collection class that creates a new base object and adds it to the collection in one single step. In most cases, this double operation makes a lot of sense. For example, a CInvoiceLine object would have a very hard life outside a parent CInvoiceLines collection. (Have you ever seen a lone invoice line wandering around all by itself in the external world?) It turns out that such a constructor is just a variant of the Add method:

Function Create(Qty As Long, Product As String, UnitPrice As Currency) _
    As CInvoiceLine
    Dim newItem As New CInvoiceLine ' Auto-instancing is safe here.
    newItem.Init Qty, Product, UnitPrice
    m_InvoiceLines.Add newItem
    Set Create = newItem            ' Return the item just created.
End Function

' In the client code
inv.InvoiceLines.Create 10, "Monitor ZX100", 225.25
inv.InvoiceLines.Create 14, "101-key Keyboard", 19.99

A key difference between the Add and the Create methods is that the latter also returns the object just added to the collection, which is never strictly necessary with Add (because you already have a reference to it). This greatly simplifies how you write your client code. For example, say that the CInvoiceLine object supports two new properties, Color and Notes. Both are optional, and as such they shouldn't be included among the required arguments of the Create method. But you can still set them using a concise and efficient syntax, as follows:

With inv.InvoiceLines.Create(14, "101-key Keyboard", 19.99)
    .Color = "Blue"
    .Notes = "Special layout"
End With

Depending on the nature of the specific problem, you can build your collection classes with both the Add and Create methods, or you can just use one of the two. It's important, however, that if you leave the Add method in the collection, you add some form of validation to it. In most cases—but not always—you just need to let the class validate itself, as in this code:

Sub Add(newItem As CinvoiceLine)
    newItem.Init newItem.Qty, newItem.Product, newItem.UnitPrice
    ' Add to the collection only if no error was raised.
    m_InvoiceLines.Add newItem, Key
End Sub

If you have encapsulated an inner class into its parent collection class in such a robust way, it's impossible for any developer to accidentally or intentionally add an incoherent object to the system. The worst that they can do is create a disconnected CInvoiceLine object, but they won't be able to add it to your self-protected CInvoice object.

Full-Fledged Hierarchies

Once you know how to create efficient collection classes, there isn't much to stop you from building complex and incredibly powerful object hierarchies, such as those exposed by well-known models Microsoft Word, Microsoft Excel, DAO, RDO, ADO, and so on. You already have all the pieces in the right places and only need to take care of details. Let me show you a few recurring problems when building hierarchies and how you can fix them.

Class static data

When you build a complex hierarchy, you're often faced with the following problem: How can all the objects of a given class share a common variable? For example, it would be great if the CInvoice class were able to correctly set its Number property in its Class_Initialize event so that from that point on Number could be exposed as a read-only property. This would improve the formal correctness of the class because it would guarantee that there aren't two invoices with the same number. This problem would be quickly solved if it were possible to define class static variables in the class module, that is, variables that are shared among all the instances of the class itself. But this is beyond the current capabilities of the VBA language.

The easy and obvious solution to this problem is to use a global variable in a BAS module, but that would break the class's encapsulation because anyone could modify this variable. Any other similar approach—such as storing the value in a file, in a database, in the Registry, and so on—is subject to the same problem. Fortunately, the solution is really simple: Use a parent collection class to gather all the instances of the class that share the common value. Not only do you solve the specific problem, you can also provide a more robust constructor for the base class itself. In the CInvoice sample program, you can create a CInvoices collection class:

' The CInvoices Collection class
Private m_LastInvoiceNumber As Long
Private m_Invoices As New Collection

' The number used for the last invoice (read-only)
Public Property Get LastInvoiceNumber() As Long
    LastInvoiceNumber = m_LastInvoiceNumber
End Property

' Create a new CInvoice item, and add it to the private collection.
Function Create(InvDate As Date, Customer As CCustomer) As CInvoice
    Dim newItem As New CInvoice
    ' Don't increment the internal variable yet!
    newItem.Init m_LastInvoiceNumber + 1, InvDate, Customer
    ' Add to the internal collection, using the number as a key.
    m_Invoices.Add newItem, CStr(newItem.Number)
    ' Increment the internal variable now, if no error occurred.
    m_LastInvoiceNumber = m_LastInvoiceNumber + 1
    ' Return the new item to the caller.
    Set Create = newItem
End Function
' Other procedures in the CInvoices collection class ... (omitted)

Similarly, you can create a CCustomers collection class (not shown here) that creates and manages all the CCustomer objects in the application. Now your client code can create both CInvoice and CCustomer objects in a safe way:

' These variables are shared in the application.
Dim Invoices As New CInvoices
Dim Customers As New CCustomers

Dim inv As CInvoice, cust As CCustomer
' First create a customer.
Set cust = Customers.Create("Tech Eleven, Inc")
cust.Address.Init "234 East Road", "Chicago", "IL", "12345"
' Now create the invoice.
Set inv = Invoices.Create("12 Sept 1998", cust)

At this point, you can complete your artwork by creating a top-level class named CCompany, which exposes all the collections as properties:

' The CCompany class (the company that sends the invoices)
Public Name As String 
Public Address As CAddress
Public Customers As New CCustomers
Public Invoices As New CInvoices
' The next two collections are not implemented on the companion CD.
Public Orders As New COrders           
Public Products As New CProducts       

You enjoy many advantages when you encapsulate classes in this way, a few of which aren't immediately apparent. Just to give you an idea of the potential of this approach, let's say that your boss asks you to add support for multiple companies. It won't be a walk in the park, but you can do it relatively effortlessly by creating a new CCompanies collection class. Since the CCompany object is well isolated from its surroundings, you can reuse entire modules without the risk of unexpected side effects.

Backpointers

When you deal with hierarchies, a dependent object frequently needs access to its parent; for example, to query one of its properties or to invoke its methods. The natural way to do that is to add a backpointer to the inner class. A backpointer is an explicit object reference to its parent object. This can be a Public property or a Private variable.

Let's see how this fits in our sample invoicing application. Say that when an invoice prints itself it should add a warning to the customer if there are any other invoices that must be paid and tell the customer the total sum due. To do this, the CInvoice class must scan its parent CInvoices collection and so needs a pointer to it. By convention, this backpointer is named Parent or Collection, but feel free to use whatever name you prefer. If you want to make this pointer Public, it's essential that it be a read-only property, at least from the outside of the project. (Otherwise, anyone could detach an invoice from the CInvoices collection.) You can achieve this by making the pointer's Property Set procedure with a Friend scope:

' In the CInvoice class
Public Paid As Boolean
Private m_Collection As CInvoices         ' The actual backpointer

Public Property Get Collection() As CInvoices
    Set Collection = m_Collection
End Property
Friend Property Set Collection(newValue As CInvoices)
    Set m_Collection = newValue
End Property

The parent CInvoices collection class is now responsible for correctly setting up this backpointer, which it does in the Create constructor method:

' Inside the CInvoices' Create method (rest of the code omitted)
newItem.Init m_LastInvoiceNumber + 1, InvDate, Customer
Set newItem.Collection = Me

Now the CInvoice class knows how to encourage recalcitrant customers to pay their bills, as you can see in Figure 7-9 and in the following code:

Sub PrintNotes(obj As Object)
    ' Print a note if customer has other unpaid invoices. 
    Dim inv As CInvoice, Found As Long, Total As Currency
    For Each inv In Collection
        If inv Is Me Then
            ' Don't consider the current invoice!
        ElseIf (inv.Customer Is Customer) And inv.Paid = False
		Then
            Found = Found + 1
            Total = Total + inv.GrandTotal
        End If
    Next
    If Found Then
        obj.Print "WARNING: Other " & Found & _
            " invoices still waiting to be paid ($" & Total & ")"
    End If
End Sub

Click to view at full size.

Figure 7-9. Don't let the rudimentary user interface fool you: There are as many as eight classes working together to supply you with a skeleton for a robust invoicing application.

Circular references

No description of object hierarchies would be complete without an acknowledgment of the circular reference problem. In short, you have a circular reference when two objects point to each other, either directly or indirectly (that is, through intermediate objects). The invoicing hierarchy didn't include a circular reference until you added the Collection backpointer to the CInvoice class. What makes circular references a problem is that the two involved objects will keep each other alive indefinitely. This isn't a surprise; it's just the same old rule that governs the lives of objects.

In this case, unless we take appropriate steps, the reference counter of the two objects will never decrease to 0, even if the main application has released all its references to them. This means that you have to forego a portion of memory until the application comes to an end and wait for Visual Basic to return all its memory to Windows. It isn't just a matter of wasted memory: In many sophisticated hierarchies, the robustness of the entire system often depends on the code inside the Class_Terminate event (for example, to store properties back in the database). When the application ends, Visual Basic correctly calls the Class_Terminate event in all objects that are still alive, but that might happen after the main application has already closed its own files. The likely result is a corrupted database.

Now that I have warned you about all the possible nasty consequences of circular references, let me scare you even more: Visual Basic doesn't offer any definitive solution to this problem. You have only two half-solutions, both of which are largely unsatisfactory: You avoid circular references in the first place, and you manually undo all circular references before your application destroys the object reference.

In the invoicing example, you can avoid backpointers if you let the inner CInvoice class access its parent collection using a global variable. But you know that this is forbidden behavior that would break class encapsulation and would compromise the application's robustness. The second solution—manually undoing all circular references—is often too difficult when dealing with complex hierarchies. Above all, it would force you to add tons of error-handling code, just to be sure that no object variable is automatically set to Nothing by Visual Basic before you have the opportunity to resolve all the existing circular references.

The only good news I can tell you is that this problem can be solved, but it requires some really advanced, low-level programming techniques based on the concept of weak object pointers. This technique is well beyond the scope of this book, and for this reason I won't show any code here. However, the bravest of you might have a look at the CInvoice class on the companion CD. I have bracketed these special advanced sections using #If directives, so you can easily see what happens using regular and weak object pointers. You probably need to review how objects are stored in memory and what an object variable really is (see Chapter 6), but the comments in the code should help you understand what the code actually does. Be sure to study this technique before using it in your own applications because when you play with objects at this low level, any mistake causes a GPF.

The Class Builder Add-In

Visual Basic 6 comes with a revamped version of the Class Builder Add-In. This is a major utility that lets you design the structure of a class hierarchy, create new classes and collection classes, and define their interfaces down to the attributes of each property, method, or event. (See Figure 7-10.) The new version adds support for enumerated properties and optional arguments of any data type, as well as a few minor enhancements.

The Class Builder Add-In is installed by the Visual Basic 6 setup routine, so you just have to open the Add-In Manager dialog box and double-click on the VB6 Class Builder Utility. When you close the window, a new item in the Add-In menu lets you invoke the utility.

Click to view at full size.

Figure 7-10. The Class Builder Add-In. A child class (CPoint, in this case) always corresponds to a property in its parent class (CLine).

Using the Class Builder Add-In is very simple, and I won't show in detail how you create new classes and their properties and methods. Its user interface is so clear that you won't have any problem using it. Instead, I focus on a few key points that can help you get the most from this utility.

A drawback of the Class Builder is that you have no control over the code it generates. For example, it uses particular naming conventions for arguments and variables and adds a lot of pretty useless remarks, which you'll probably want to delete as soon as you can. Another issue is that once you begin to use it in a project, you're virtually forced to invoke it any time you want to add a new class—otherwise, it won't be able to correctly place the new class in the hierarchy. Even with these limitations, you'll find that creating hierarchies with the Class Builder Add-In is so simple that you can easily get carried away.

This chapter concludes our journey in object-oriented land. If you care about well-designed software and code reuse, you'll surely agree with me that OOP is a fascinating technology. When working with Visual Basic, however, a firm understanding of how classes and objects work is necessary to tackle many other technologies, including database, client/server, COM, and Internet programming. I'll frequently use, in the remaining sections of this book, all the concepts you've encountered in this chapter.